Pull to refresh

How to cook reactive programming. Part 2: Side effects

Reading time 13 min
Views 2.9K

Despite the number, this is the third article about reactive programming. Today we are going to talk about how to handle side effects while using unidirectional approaches.


Before we start, I’d firstly highly recommend reading at least How to cook reactive programming. Part 1: Unidirectional architectures introduction.. However, if you’re not familiar with frameworks such as RxSwift or Combine, or reactive programming in general, I’d suggest reading this article as well.

  1. What is Reactive Programming? iOS Edition
  2. How to cook reactive programming. Part 1: Unidirectional architectures introduction.

Intro


Before we will move to the talk about Side Effects I want to introduce you to the main subject of this article.



This is a representation of the simplest State which you actually can find in nearly every application. Let me transform this image into the real code.


enum State {
    case initial
    case loading
    case loaded(data: [String])
}

Much better! If you’re familiar with a state machine theory from the computer science class, this picture will be very recognisable to you. This is a simple state machine with 3 states. The Initial state can go to the Loading state. The Loading state to the Loaded state. And the Loaded state can go back to the Loading state. Remember I was talking about State data consistency. In this particular case it's really hard to make the state inconsistent. Each state of the system is represented as an enum case.


Don't worry, I'm not going to bother you with any computer science concepts here. It was mostly a representation of the ideal State which could be achieved. In the real world it's really hard to create only an enum state. In most cases it would be a structure. However, for the purposes of this article we will use this State for the experiments. And now let's move to the main topic.


What are Side Effects?


According to Wikipedia


In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the main effect) to the invoker of the operation. State data updated "outside" of the operation may be maintained "inside" a stateful object or a wider stateful system within which the operation is performed. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other side-effect functions. In the presence of side effects, a program's behaviour may depend on history; that is, the order of evaluation matters. Understanding and debugging a function with side effects requires knowledge about the context and its possible histories.

However, here we’re not talking about the strict definition of the side effects. Let's remember where we ended up the last time.


struct State {
    var value: Int?

    static func reduce(state: State, event: Event) -> State {
        var state = state
        switch event {
        case .changeValue(let newValue):
            state.value = newValue
        }
        return state
    }
}

class Store {
    var state: State

    func accept(event: Event) {
        state = reduce(state: state, event: event)
    }
}

Generally, nearly all unidirectional architectures look like this. We have a State on which the rest application relies as only one source of truth. A reducer which alongside Event is the only way to mutate or update State. However, there should be something else. We don't live in a synchronous world, where every update for State can be done only with a synchronous reduce function. Every application needs to go to the network or database, for the new cat images. Every developer moves even hard computations on the background thread. So, how will all of this work with the existing code? The answer is side effects. In our case Side Effects are something asynchronous, which could mutate State and this "something" works on the side of the reducer. Imagine your beloved network service which somehow needs to be connected to the rest of the system. But first let's talk about why this architecture is called ‘unidirectional’.


One remark: Event in different implementations of unidirectional architectures could be called a Mutation or Action or Message, or maybe something different, for our purposes however naming is not so important.


Why is the architecture called unidirectional?


Unidirectional architecture is also known as one-way data flow. This means that data has one, and only one way to be transferred to other parts of the application. In essence, this means child components are not able to update the data that is coming from the parent component. The main benefit of this approach is that data flows throughout your app in a single direction, giving you better control over it.


I think it should be quite easy to understand with theState reduce approach from the beginning. We can change or mutate State only with a strict described Event. As a result we've got a one-way (unidirectional) data flow. However, what should we do with Side Effects?


Imagine that for the State we have a service which as a result returns a list of the news titles.


func loadNewsTitles(completionHandler: ([String]) -> ()) {
    completionHandler(["title1", "title2"])
}

We know that reducer takes Event as an input not a closure… How can we connect this service to the reducer? The answer is quite simple. Let’s have a Side Effect, which will return Event, not just requested data.


The resulting system will look like this:


enum State {
    case initial
    case loading
    case loaded(data: [String])
}

enum Event {
    case dataLoaded(data: [String])
    case loadData
}

func loadNewsTitles(completionHandler: (Event) -> ()) {
    loadNewsTitles { data in
        completionHandler(.dataLoaded(data: data))
    }
}

extension State {
    static func reduce(state: State, event: Event) -> State {
        var state = state
        switch event {
        case .dataLoaded(let data):
            state = .loaded(data: data)
        case .loadData:
            state = .loading
        }
        return state
    }
}

As you can see for now loadNewsTitles returns an Event, which could mutate the state. Our system works only in a unidirectional way. And there's an answer to why the architecture is called Unidirectional. After I’d answered one question, I've subsequently produced another one. How can we connect Side Effects and the rest of the system? This question actually is the most complicated so far. I'll try to answer it in the next section.


Which types of side effects exist?


In nearly every unidirectional architecture you'll see a collaboration of State and some function for reducing this State according to the input Event. With Side Effects it's much more complicated. Almost every framework does this in a different way. Let me try to make you familiar with the most popular of them.


Middleware


Let's start with the Middleware approach. Middleware provides a third-party extension point between dispatching an Event, and the moment it reaches the reducer. In simple terms Middleware, sits in the middle between you performing or dispatching an Event and mutating your State inside the reducer.



Let me provide you with a code example, which I found in one well-known framework for Redux implementation for Swift.


let middleware: Middleware = { store, getState in
    return { next in
        return { event in
            // perform middleware logic
            switch event {
            case .loadData:
                loadNewsTitles { event in
                    store.accept(event)
                }
            case .dataLoaded:
                break
            }

            // call next middleware
            return next(event)
        }
    }
}

As you can see a Middleware could be treated as an asynchronous preReducer. It catches all Events, carries out some manipulations over it — in our case, loading news titles -, and performs a new Event for the system if it's necessary. So, if the Event is loadData, listed Middleware will load news titles and in the closure send another Event to the Store. The next dataLoaded Event will just be ignored by this Middleware. One of the pros of this method is the possibility to chain Middlewares quite easily.


Also, if you want to read more about this approach, I’d highly recommend taking a look at ReSwift framework. This framework is an implementation of a unidirectional architecture, which is called Redux for Swift language. For those, who still refuse reactive frameworks, ReSwift could be a good start, because ReSwift doesn't use any.


Effects


The next approach I want to talk about is the Effects approach. The main idea is almost the same as the Middleware, but all actions are going on inside Reducer itself.



In this approach Reducer has a little bit of a different shape, that I showed you before. It has a shape, which you can see below.


func reducer(state: inout State, event: Event, environment: Environment) -> Effect

Nearly everything should be familiar. State is a type that holds the current state of the application. Event is a type that holds all possible events that cause the state of the application to change. However, there are two new characters: Environment and Effect. Environment is a type that holds all dependencies needed in order to produce Effect(s), such as API clients, analytics clients, random number generators, and so on.


So, how does it work for our example?


struct Environment {
    let loadNewsTitles: (Event) -> ()
}

struct Effect {
    init(work: ((State) -> ())? = nil)
    func performWorkItem() -> Event
}

extension Effect {
    /// An effect that does nothing and completes immediately.
    static let none = Effect()
}

extension Effect {
    static func loadNewTitlesEffect(loadNewsTitles: (Event) -> ()) -> Effect
}

func reducer(state: inout State, event: Event, environment: Environment) -> Effect {
    switch event {
    case .dataLoaded(data: let data):
        state = .loaded(data: data)
        return .none
    case .loadData:
        return Effect.loadNewTitlesEffect(loadNewsTitles: environment.loadNewsTitles)
    }
}

How does this approach work? For every call of Reducer you provide all necessary dependencies via Environment to the Reducer itself, and afterward your Store will perform every workItem from the Effect itself. And then every Effect will return another Event to the Reducer. Unidirectional data flow works with all power here.


You may ask, but what about the pure Reducer over there? You told us that Reducer is a pure function, and now you put Side Effects directly inside this function. Moreover, we mutate State inside this function, not just creating a new value. So, I can definitely explain that this variation of the Reducer is the most complicated one which we've seen so far. It has the Environment inside and it mutates State. However, let’s take a closer look. If we provide one implementation for the loadNewTitles service, our Reducer will perform the same and our State in the end will be the same. Yeah, in the real world, our server can answer with the different replies or different news titles, but it still has the same output — Effect as a return value, with the same network client in it. I hope you’ve got the idea. What about State mutation? Since all real mutations are always going on inside the Store, the main situation around changing State hasn't changed itself. Moreover, mutating State against creating new values for every reduce saves some performance for us. We don't need to allocate new memory each time.


I don't want to provide a working example of this approach as well. My job is to make you familiar with it and explain the basics. However, I highly recommend taking a look at The Composable Architecture TCA from pointfree.co. In my personal opinion this framework is the most promising for now. It has its own cons such as the minimum iOS 13 version. They also have a website with a lot of useful videos available on it. It's not free, but I have a promocode for you. I'm sorry I couldn't miss this chance...


Query Feedback


Let's move forward or downstairs. In contrast with Middleware or Effect approaches from the previous sections, there's a Query approach. The Query Feedback approach reacts to new changes from the different side of the Reducer compared to Middleware.



Did you notice? We’ve moved the whole way through Side Effects approaches? Middleware was before Reducer, Effects approach was inside Reducer and now Query Feedback is after reducer. Quite a journey, huh?


However, how does it work? We need to take a small piece of the State and start to Observe every change of this state. For the previous example it will look like:


extension State {
    var loadQuery: Void? {
        guard case .loading = self else { return nil }
        return ()
    }
}

In other words, there's some kind of Observer, which follows every change of this query and performs some actions over it. The optional Void type for my taste is the best representation when you need to understand whether you need to do any work or not.


I think you’ve likely become bored with non-working examples in this article. So, let's try at least to implement this one. I'll use the Combine framework for this implementation. Whoah, this is the third article about reactive programming, and only now I'll start to use a reactive framework. Also, afterward, I'll explain why I prefer to use Combine over vanilla Swift. Technically Combine is already vanilla as well, but you’ve got the point.


Here is a small table of concepts for those who are new in Combine.



From now a little bit of tutorial started. Everything that I'll write below you can copy to your project and play with it afterward.


Let's introduce our old characters: State, Reducer, Query and Event:


enum State: Equatable {
    case initial
    case loading
    case loaded(data: [String])
}

enum Event {
    case dataLoaded(data: [String])
    case loadData
}

extension State {
    var loadQuery: Bool {
        guard case .loading = self else { return false }
        return true
    }
}

extension State {
    static func reduce(state: State, event: Event) -> State {
        var state = state
        switch event {
        case .dataLoaded(let data):
            state = .loaded(data: data)
        case .loadData:
            state = .loading
        }
        return state
    }
}

Nothing new so far — only a Query which I've shown to you recently. Now let's remove the callback from loadNewsTitles service and rewrite it in Combine fashion.


func loadNewsTitles() -> AnyPublisher<[String], Never> {
    ["title1", "title2"]
        .publisher
        .delay(for: .microseconds(500), scheduler: DispatchQueue.main)
        .collect()
        .eraseToAnyPublisher()
}

Mostly it's just a pre-prepared mock with a small delay, which should simulate a real network environment. And now there's a new character in this play. Let's call it SideEffects. Obviously it’s not me who invented this name, but let's imagine it for the bigger narrative of the story.


struct SideEffects {
    let loadNewTitles: () -> AnyPublisher<[String], Never>

    func downloadNewTitles() -> AnyPublisher<Event, Never> {
        loadNewTitles()
            .map(Event.dataLoaded)
            .eraseToAnyPublisher()
    }
}

As far as you can see, SideEffects for the Query Feedback approach is almost the same thing, as Environment for the Effect approach. I prefer to keep it as simple as possible, and most of the time it just converts the output from the services into Event which could be consumed by the Reducer.


And for now, there’s only one question left- how do we connect SideEffects with the rest of the system? The answer isn’t so complicated, and Combine helps with it very much. Let's build our boss Store entity in which we'll connect every piece of our system.


typealias Reducer = (State, Event) -> State

class Store {
    @Published private(set) var state: State
    private let reducer: Reducer
    private let sideEffects: SideEffects

    init(
        initialState: State,
        reducer: @escaping Reducer,
        sideEffects: SideEffects
    ) {
        self.state = initialState
        self.reducer = reducer
        self.sideEffects = sideEffects
    }

    func accept(event: Event) {
        state = reducer(state, event)
    }

    func start() -> AnyCancellable {
        $state
            .map(\.loadQuery)
            .removeDuplicates()
            .filter { $0 == true }
            .map { _ in () }
            .flatMap(sideEffects.downloadNewTitles)
            .sink(receiveValue: accept(event:))
    }
}

The most interesting part of the code listing above is the start function. As far as you can see, I made our SideEffects react to the change of the piece of the State loadQuery. And for every time when our system will be in the loading State, our SideEffects will go to the network service, download new newsTitles and notify our system that new titles have been downloaded. Do you see it? Everything in the cycle, all data flow works in the one direction.


Let's test what I've done so far.


let store = Store(
    initialState: .initial,
    reducer: State.reduce(state:event:),
    sideEffects: SideEffects(
        loadNewTitles: loadNewsTitles
    )
)
var cancellables: [AnyCancellable] = []

store
    .start()
    .store(in: &cancellables)

store
    .$state
    .removeDuplicates()
    .sink { state in print(state) }
    .store(in: &cancellables)

store.accept(event: .loadData)

// Console output

// initial
// loading
// loaded(data: ["title1", "title2"])

And it works, as expected! The system started from initial state then it went to the loading state after the new Event was sent, and ended up in loaded state. If you want to play with it some more, I've prepared a gist.


I know that two Cancelables could look a little bit clumsy here, but I didn't want to make this example too complicated. There's another framework called RxFeedback where all these problems were solved. I think that you've already got it, that this framework uses RxSwift from the title, right? However, there's a constructor of Observable — it's a Publisher from the Combine — which creates the whole unidirectional system for you.


typealias Feedback<State, Event> = (Observable<State>) -> Observable<Event>

extension Observable {
    public static func system<State, Event>(
        initialState: State,
        reduce: @escaping (State, Event) -> State,
        feedback: Feedback<State, Event>...
    ) -> Observable<State>
}

Typealias Feedback is a SideEffect itself. It takes changes in the State as an input and provides a sequence of Events as output.


In my personal opinion, this approach is the most hardcore one. As an advantage, you can take that your State always reflects what's going on in the system. The previous two rely on Event while doing any Side Effects, but this one only relies on the State itself. If you want to read a little bit more about the pros and cons of the Effect and Query approaches, you could read my discussion with TCA creators.


Why do we need a reactive framework for this?


There are a lot of people who don't want to accept any reactive frameworks and don't understand why they are even needed. If you’re still reading this, and you are one of them, crash the like or clap button. This section is mostly for those who’ve been intrigued by the Unidirectional approach, but for some reason don't want to use reactive frameworks. Firstly I want to say, that you've already seen in my articles, that there’s nothing to be scared by inreactive frameworks and reactive programming in general. Most of you already use some techniques from it. I can say that it's much more handy to handle your data like a sequence or array than work with enormous closures. Use some functions, like filter, map or reduce etc. It really makes your code more clean and understandable. It's really hard to make a lot of mistakes from the start if you don't know how to cook it. That's why I write these articles for you.


Let me show you another advantage in a reactive framework usage. Do you remember that I relied on ReSwift framework while showing a Middleware approach? This is a great framework, which was written by brilliant people. However, if you try to understand how it works under the hood, or even try to work with it you will end up with structures like this.


    /// Creates a middleware function using SimpleMiddleware to create a ReSwift Middleware function.
    func createMiddleware<State: StateType>(_ middleware: @escaping SimpleMiddleware<State>) -> Middleware<State> {

        return { dispatch, getState in
            return { next in
                return { action in

                    let context = MiddlewareContext(dispatch: dispatch, getState: getState, next: next)
                    if let newAction = middleware(action, context) {
                        next(newAction)
                    }
                }
            }
        }
    }

There are three closures inside each other! Moreover, I've used a helper to make it more simple. Of course, you could separate all of this somehow and avoid all the callback hell. However, if you take a look at my previous Query example you will see how everything was simple and straightforward. I wanted to say elegant as well, but for elegance it has to be refactored a little bit. If you want to have a closer look at ReSwift in action, I did a small test project some time ago.


Outro


There's no silver bullet on how to handle Side Effects. You can decide for yourself what to use. However, I think that most of you and myself will choose some pre-prepared solution like RxFeedback or TCA or ReSwift or something else.



Finally, I have a chance to show you this gif. I took it from the ReSwift repo. This gif in full form represents the whole power of Unidirectional approaches. Technically you can store the whole history of State mutations and replay them at any moment you want.


So far, you've become familiar with how to work and even how to build your own reactive framework and unidirectional architecture. However, we live in a world where applications are not one button flashlight apps anymore. We have teams of more than ten people. And if you noticed, the main idea of Unidirectional architecture is to keep all data inside one struct. I bet, if you start with this approach you will end up with a huge State and Reducer if not at the end of the week, then by at the end of the month. You may wonder how it's possible to separate the Unidirectional approach on different modules when the main idea is to keep everything in one place. What to do in this situation and what are the ways of app modularization I will show you in the next article. Let's keep in touch!


If you don't want to lose any new articles subscribe to my twitter account))


Twitter(.atimca)
.subscribe(onNext: { newArcticle in
    you.read(newArticle)
})
Tags:
Hubs:
0
Comments 0
Comments Leave a comment

Articles