DEV Community

loading...

How to cook reactive programming. Part 3: Modularization.

Maxim Smirnov
・15 min read

Last time we were talking about different types of Side Effects, and moreover I showed you some libraries which support Unidirectional data flow approaches. I can certainly say if you’ve already read all the previous articles you now have a clear idea of how reactive programming works, and how to build a unidirectional architecture by yourself.

I would highly recommend firstly reading at least the two previous articles. However, if you’re not familiar with frameworks such as RxSwift or Combine, or reactive programming in general, I’d suggest reading my first article as well.

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

As I mentioned in my previous article, we live in a world where applications are not one button flashlight apps any more. We have teams of more than ten people. And if you noticed, the main idea of Unidirectional architecture is to keep all data in one place. I bet, if you start with this approach, you’ll end up with a huge State and Reducer if not at the end of the week, then certainly by 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. That’s exactly what I'm going to show you today.

Today I will show you two approaches for Unidirectional architecture modularization. I picked these two not because there are not more out there - you can easily find them if you search the internet. I picked them because they are opposite to one another. As a starting point, I picked a module that I want to use for experiments from the previous chapter.

struct User {
    let id: AnyHashable
}

struct NewsState {
    var user: User
    var step: Step
}

extension NewsState {
    enum Step {
        case initial
        case loading
        case loaded(data: [String])
    }
}

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

extension NewsState {
    static func reduce(state: NewsState, event: NewsEvent) -> NewsState {
        var state = state
        switch event {
        case .dataLoaded(let data):
            state.step = .loaded(data: data)
        case .loadData:
            state.step = .loading
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

But this time we need an app, where all the magic will happen. I’d like to keep it as simple as possible.

struct AppState {
    var user: User
}

enum AppEvent {
    case newUserWasLoaded(User)
}

extension AppEvent {
    static func reduce(state: AppState, event: AppEvent) -> AppState {
        var state = state
        switch event {
        case .newUserWasLoaded(let user):
            state.user = user
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

Imagine you have a small module like I've shown you recently, which can download news titles. Let's just name it News. And you have the app itself, let's just call it App. Quite a simple module I’d say, but this time we need a User who’s shared within the whole app. Moreover, let's add a situation when the main app should know about the loading step from the news module, for example, it has another page, which depends on the news titles. So, let's start with the first approach on how to connect it to the main app. A list of requirements would look like this:

  • The App needs to know about all changes in the News
  • The News needs to know about all changes in the part of the App (User)

I think these requirements are simple and are enough for showing modularization concepts. So, let's start with the first approach.

Composable Reducers

If you read my previous article, I bet you've already understood what architecture I'm going to talk about. Composable Architecture provides a really good way for modularization. What do they do?

The main idea of the modularization with the composable approach is to store every Event and every State in the App module. Let's change the app a little bit to conform to the new requirement.

struct AppState {
    var user: User
    var newsState: NewsState?
}

enum AppEvent {
    case newUserWasLoaded(User)
    case newsEvents(NewsEvent)
}
Enter fullscreen mode Exit fullscreen mode

So far, I've just put everything inside the App itself. We’ll talk about problems with this approach a little bit later. For now, we need to proceed and solve the next problem. How would this system work? Maybe you've noticed that I omitted reducer for the new app variation. I did this on purpose because the entire work which should be done exists in the reducer itself.

Let's try to solve this problem as it stands with building a new reducer.

extension AppEvent {
    static func reduce(state: AppState, event: AppEvent) -> AppState {
        var state = state
        switch event {
        case .newUserWasLoaded(let user):
            state.user = user
        case .newsEvents(let event):
            switch event {
            case .dataLoaded(let data):
                state.newsState?.step = .loaded(data: data)
            case .loadData:
                state.newsState?.step = .loading
            }
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

It's a little bit clumsy, but it works. However, I've just generated another problem. NewsState is optional and should be created somewhere. Let's try to create it the first time when .newsEvents have appeared. The final reducer would look like this:

extension AppEvent {
    static func reduce(state: AppState, event: AppEvent) -> AppState {
        var state = state
        switch event {
        case .newUserWasLoaded(let user):
            state.user = user
        case .newsEvents(let event):
            if state.newsState == nil {
                state.newsState = NewsState(user: state.user, step: .initial)
            }
            switch event {
            case .dataLoaded(let data):
                state.newsState?.step = .loaded(data: data)
            case .loadData:
                state.newsState?.step = .loading
            }
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the system works. However, it doesn't look like a modularization at all. All actions happen inside one function in the main application. Let's try to fix it a little bit. We've already had a reduce function for the News module. I want to try to adopt it as is to the previously implemented reducer.

extension AppEvent {
    static func reduce(state: AppState, event: AppEvent) -> AppState {
        var state = state
        switch event {
        case .newUserWasLoaded(let user):
            state.user = user
        case .newsEvents(let event):
            if state.newsState == nil {
                state.newsState = NewsState(user: state.user, step: .initial)
            }
            state.newsState = NewsState.reduce(state: state.newsState!, event: event)
        }
        return state
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we have the composition of the most primitive function. It looks better, but it’s still not what we would expect from modularization. Could we somehow improve this composition? Of course we can - in the end, I'm not inventing here anything.

I’d suggest starting with the functions declaration first. What do we already have? We have two reducers, which work like one reducer. What does that mean? It means we want a function which could compose two separate reducers into one. So, for the first step, we need a reducer declaration.

typealias Reducer = (AppState, AppEvent) -> AppState
Enter fullscreen mode Exit fullscreen mode

Just a type alias for the function, which takes State and Event as the input and provides a result state as an output. Ok, so there’s nothing new here. For the next step let's make a declaration for the combine function, which combines multiple reducers into one.

func combine(_ reducers: Reducer...) -> Reducer
Enter fullscreen mode Exit fullscreen mode

Things are progressing easily so far. Now with these declarations, we’ll write a realization for the combine function. We just need to chain every reducer with the provided state and event.

func combine(_ reducers: Reducer...) -> Reducer {
    return { state, event in
        reducers.reduce(state) { state, reducer in
            reducer(state, event)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This function could be realized with a simple “for” loop, however, the usage of the reduce function inside our combine looks more symbolic, doesn't it? Let's try out what we've done so far.

let firstReducer: Reducer = { state, event in
    var state = state
    switch event {
    case .newUserWasLoaded(let user):
        state.user = user
    default:
        return state
    }
    return state
}

let secondReducer: Reducer = { state, event in
    var state = state
    switch event {
    case .newsEvents:
        state.newsState?.step = .loading
    default:
        return state
    }
    return state
}

let resultReducer = combine(firstReducer, secondReducer)
let initialState = AppState(user: User(id: ""), newsState: NewsState(user: User(id: ""), step: .initial))

let firstMutation = resultReducer(initialState, .newUserWasLoaded(User(id: 1)))
let secondMutation = resultReducer(firstMutation, .newsEvents(.loadData))

print(secondMutation.user.id)
print(secondMutation.newsState!.step)

// Console output:
// 1
// loading
Enter fullscreen mode Exit fullscreen mode

What have I just done? I've created two reducers, combined them into one result reducer, and performed two mutations. As a result you can see that two completely separated reducers mutate one state, which is a proof of concept. However, I created another problem, and I've started to have the feeling that I'm only creating problems, not solving them. The combine function can work only over one type of state - AppState- and one type of event - AppEvent. We have the News module, which has different state and event types. How could this situation be resolved? Maybe you've already guessed, according to my first iterations, that NewsState, now a part of AppState and NewsEvent, is a part of AppEvent and we could apply some conversions to fulfill combine function requirements. But what should we do exactly? We need to convert NewsReducer into AppReducer.

NewsReducer has this declaration:

func reduce(state: NewsState, event: NewsEvent) -> NewsState
Enter fullscreen mode Exit fullscreen mode

And AppReducer this:

func reduce(state: AppState, event: AppEvent) -> AppState
Enter fullscreen mode Exit fullscreen mode

I want to split this task into two smaller ones. Let's start with transforming

func reduce(state: NewsState, event: AppEvent) -> NewsState
Enter fullscreen mode Exit fullscreen mode

into

func reduce(state: AppState, event: AppEvent) -> AppState
Enter fullscreen mode Exit fullscreen mode

As usual I want to start with a function definition:

func transform(newsReducer: (NewsState, AppEvent) -> NewsState) -> Reducer
Enter fullscreen mode Exit fullscreen mode

Because Reducer is a function itself, we need to build a function inside another function 🤯. The first step will look like this:

func transform(newsReducer: (NewsState, AppEvent) -> NewsState) -> Reducer {
    return { appState, appEvent in
        ???
    }
}
Enter fullscreen mode Exit fullscreen mode

To build a function which uses AppEvent and AppState we have NewsState and AppEvent. What can we do here? Because we need to put NewsState inside NewsReducer we need to somehow transform AppState into NewsState:

func transform(
    newsReducer: (NewsState, AppEvent) -> NewsState,
    appStateIntoNews: (AppState) -> NewsState
) -> Reducer {
    return { appState, appEvent in
        let newsState = appStateIntoNews(appState)
        return newsReducer(newsState, appEvent)
    }
}
Enter fullscreen mode Exit fullscreen mode

We’re Halfway there, but it's not enough, as we've got an error

Cannot convert return expression of type 'NewsState' to return type 'AppState'

It means that we need somehow to transform the ews reducer result into AppState. We already know that NewsState is a part of the AppState, so it shouldn’t be so hard.

func transform(
    newsReducer: @escaping (NewsState, AppEvent) -> NewsState,
    appStateIntoNews: @escaping (AppState) -> NewsState
) -> Reducer {
    return { appState, appEvent in
        var appState = appState
        let newsState = appStateIntoNews(appState)
        let newNewsState = newsReducer(newsState, appEvent)
        appState.newsState = newNewsState
        return appState
    }
}
Enter fullscreen mode Exit fullscreen mode

It will work, I can assure you. However, it doesn't look generic enough for usage for different applications with different state interfaces. So let's fix it.

func transform(
    newsReducer: @escaping (NewsState, AppEvent) -> NewsState,
    appStateIntoNews: @escaping (AppState) -> NewsState,
    mutateAppStateWithNewsState: @escaping (AppState, NewsState) -> AppState
) -> Reducer {
    return { appState, appEvent in
        let newsState = appStateIntoNews(appState)
        let newNewsState = newsReducer(newsState, appEvent)
        return mutateAppStateWithNewsState(appState, newNewsState)
    }
}
Enter fullscreen mode Exit fullscreen mode

Much better, but we could refactor it a little bit more with a KeyPath usage, and decrease the number of input parameters of the function, so let's try it out.

func transform(
    newsReducer: @escaping (NewsState, AppEvent) -> NewsState,
    stateKeyPath: WritableKeyPath<AppState, NewsState>
) -> Reducer {
    return { appState, appEvent in
        var appState = appState
        appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], appEvent)
        return appState
    }
}
Enter fullscreen mode Exit fullscreen mode

That's what I’m talking about. Let's move forward, we still have unfinished business according to events. The desired transform function will look like this:

func transform(newsReducer: (NewsState, NewsEvent) -> NewsState) -> Reducer
Enter fullscreen mode Exit fullscreen mode

Let's start with an update to the existing one:

func transform(
    newsReducer: @escaping (NewsState, NewsEvent) -> NewsState,
    stateKeyPath: WritableKeyPath<AppState, NewsState>
) -> Reducer {
    return { appState, appEvent in
        var appState = appState
        appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], appEvent)
        return appState
    }
}
Enter fullscreen mode Exit fullscreen mode

This code will show another error:

Cannot convert value of type 'AppEvent' to expected argument type 'NewsEvent'

As far as you can see a Swift compiler really helps in these kinds of things, as throughout I'm just doing whatever it says to me. And now we need to convert AppEvent into NewsEvent. We've already made one as a part of another. Now we should make one small addition to make things easier.

enum AppEvent {
    case newUserWasLoaded(User)
    case newsEvents(NewsEvent)

    var newsEvent: NewsEvent? {
        guard case .newsEvents(let event) = self else { return nil }
        return event
    }
}
Enter fullscreen mode Exit fullscreen mode

We have a newsEvent property, which allows us to easily convert AppEvent into NewsEvent if it's possible. I'm going to add this update and fix the error.

func transform(
    newsReducer: @escaping (NewsState, NewsEvent) -> NewsState,
    stateKeyPath: WritableKeyPath<AppState, NewsState>
) -> Reducer {
    return { appState, appEvent in
        guard let newsEvent = appEvent.newsEvent else { return appState }
        var appState = appState
        appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], newsEvent)
        return appState
    }
}
Enter fullscreen mode Exit fullscreen mode

In this case, if the app event was a news event we will perform a news reducer. However, the same small change should be added to make the transform function more generic. What have we done so far? We converted AppEvent into NewsEvent, so should we just add a conversion function as another parameter of the transform function?

func transform(
    newsReducer: @escaping (NewsState, NewsEvent) -> NewsState,
    stateKeyPath: WritableKeyPath<AppState, NewsState>,
    toNewsEvent: @escaping (AppEvent) -> NewsEvent?
) -> Reducer {
    return { appState, appEvent in
        guard let newsEvent = toNewsEvent(appEvent) else { return appState }
        var appState = appState
        appState[keyPath: stateKeyPath] = newsReducer(appState[keyPath: stateKeyPath], newsEvent)
        return appState
    }
}
Enter fullscreen mode Exit fullscreen mode

Perfect! Now we have a transform function, which will really help with the reducers composition. Shall, we add the final step to make transform and combine into generic functions?

func combine<State, Event>(_ reducers: (State, Event) -> State...) -> (State, Event) -> State {
    return { state, event in
        reducers.reduce(state) { state, reducer in
            reducer(state, event)
        }
    }
}

func transform<GlobalState, GlobalEvent, LocalState, LocalEvent>(
    localReducer: @escaping (LocalState, LocalEvent) -> LocalState,
    stateKeyPath: WritableKeyPath<GlobalState, LocalState?>,
    toLocalEvent: @escaping (GlobalEvent) -> LocalEvent?
) -> (GlobalState, GlobalEvent) -> GlobalState {
    return { globalState, globalEvent in
        guard
            let localEvent = toLocalEvent(globalEvent),
            let localState = globalState[keyPath: stateKeyPath]
            else { return globalState }
        var globalState = globalState
        globalState[keyPath: stateKeyPath] = localReducer(localState, localEvent)
        return globalState
    }
}
Enter fullscreen mode Exit fullscreen mode

Voila! So it’s a small change, but we can use these functions for every reducer right now. Let's test them with our previous test case.

let appReducer: Reducer = { state, event in
    var state = state
    switch event {
    case .newUserWasLoaded(let user):
        state.user = user
    default:
        return state
    }
    return state
}


let combinedReducer = combine(
    transform(
        localReducer: NewsState.reduce(state:event:),
        stateKeyPath: \AppState.newsState,
        toLocalEvent: \AppEvent.newsEvent
    ),
    appReducer
)

let firstMutation = combinedReducer(initialState, .newUserWasLoaded(User(id: 1)))
let secondMutation = combinedReducer(firstMutation, .newsEvents(.loadData))

print(secondMutation.user.id)
print(secondMutation.newsState!.step)

// Console output:
// 1
// loading
Enter fullscreen mode Exit fullscreen mode

The output is the same as on the previous occasion, however we now have a fully generic approach to achieve this. And that's it, we've achieved a fully generic way of Unidirectional architecture modularization. It's actually quite a straightforward way to modularize your system. Before we move forward to another modularization approach, let's talk a little bit about the pros and cons.

Pros:

The true way of Unidirectional data flow. You don't lose any of the advantages of the unidirectional approach and as far as you can see the main mantra still applies - everything is in the one place, everything works in the one direction, everything is consistent. It goes from the previous point, there's no way - unless of course you deliberately make one - when you have for instance different users in different modules.

Cons:

Not true module separation. To achieve this modularization approach you need to reveal the State and Event of the underlying module to the module where you attach things. It shouldn't be a big problem if all your modules work together only in one project or several affiliated projects. However, it could cause some problems in two cases. The first case is if you just have started to use unidirectional approaches in your codebase. I bet you want to isolate and reuse modules in this case, not connect them in one place. In the second case when you work over a framework, you shouldn’t expect your customers to adopt a way of modularization, which I've just shown to you.

As I've mentioned before, what I've shown you I took from the pointfree channel. I've tried to make things a little bit different. They separated the app by modules, but I attached the existing module to the app. It’s not a big difference, but if you want to go deeper consider watching their videos as well. Also, they've explained a lot of functional programming concepts, which we used to achieve this modularization approach.

Fully separated approach

Do you remember what were the problems of the previous approach? That it’s hard to use in isolation and you have to reveal a state and event for an upper module. With this approach, you can solve these problems.

For this time we need to remember about Store.

class NewsStore {
    @Published private(set) var state: NewsState
    private let reducer: (NewsState, NewsEvent) -> NewsState

    init(
        initialState: NewsState,
        reducer: @escaping (NewsState, NewsEvent) -> NewsState
    ) {
        self.state = initialState
        self.reducer = reducer
    }

    func accept(event: NewsEvent) {
        state = reducer(state, event)
    }
}
Enter fullscreen mode Exit fullscreen mode

Why do we need Store here so badly? Because Store is an entry point of the whole module creation. We actually need it for Composable Reducers, but it’s not so bad and I wanted to save a little bit of your time.

So, how do we achieve modularization? The answer is actually more straightforward or even easier than with a previous approach. We just need to provide a way of updating information from the outside world and provide a way to notify changes from the child module. With any reactive framework, it's a super-easy task. So, let's implement it in one step.

class ViewController: UIViewController {
    var cancellables: Set<AnyCancellable> = []
}

enum InputEvent {
    case newUserWasLoaded(User)

    var user: User {
        switch self {
        case .newUserWasLoaded(let user):
            return user
        }
    }
}

enum OutputEvent {
    case newsStateStepWasUpdated(NewsState.Step)
}

class NewsStore {
    @Published private(set) var state: NewsState
    private let input: AnyPublisher<InputEvent, Never>
    private let reducer: (NewsState, NewsEvent) -> NewsState

    init(
        initialState: NewsState,
        input: AnyPublisher<InputEvent, Never>,
        reducer: @escaping (NewsState, NewsEvent) -> NewsState
    ) {
        self.state = initialState
        self.reducer = reducer
        self.input = input
    }

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

    func start() -> (UIViewController, AnyPublisher<OutputEvent, Never>) {

        let viewController = ViewController()

        input
            .map(\.user)
            .sink { self.state.user = $0 }
            .store(in: &viewController.cancellables)

        let output = $state
            .map(\.step)
            .map(OutputEvent.newsStateStepWasUpdated)
            .setFailureType(to: Never.self)
            .eraseToAnyPublisher()

        return (viewController, output)
    }
}
Enter fullscreen mode Exit fullscreen mode

What are all the nteresting things that happened in the Store? I've just added two publishers Input and Output. With input, I can observe what happened in the outside world of the module and update data, according to the input event. With output, I can cut a piece of the state, which could be interesting in the outside world. Also, there’s one small addition because the sink method produces Cancellable type, so we need to somehow utilize this sink subscription. I've attached it to the lifecycle of the view controller however, it could be done in different ways since unidirectional architecture could be used not only in the partnership with a view.

The second approach part ended up much shorter than the previous one. I hope it's because this approach is far easier to understand and to implement, not because I'm too lazy to write it step by step. Now let's talk about the pros and cons of this approach.

Pros:

True modularization. An application doesn't depend on the underlying module realization. If you want to try unidirectional approaches, it's a way to adopt this as an only part of the project. Also, it’s suitable for SDKs creation.

Cons:

It’s too easy to create the not true unidirectional state, because modules could have their own independent states and the whole app state could be inconsistent. Because it's a state-based system, sometimes you have to almost manually reset a state. Imagine the situation, that you need to open another scene from your child module. You have something like sceneForOpen property in the child module. The main module should send something, like sceneWasShown, back to the child module for clearing sceneForOpen property. Otherwise, if you go back and want to open the same page again nothing would happen, because the child's state hasn't been changed.

What about Side Effects?

Side effects realization with modularization highly depend on the way you implement these side effects.

With the second fully separated approach there's no difference in handling side effects at all. You have a fully separated module with its own side effects, when you create this module side effects will work, as I've shown in the previous article.

On the other hand, we have an approach with composable reducers. And thus I don't want to repeat the pointfree.co Effects approach to modularization. You can easily find an explanation in their videos or on github.

However, I want to show you at least something. Composable architecture has its effects system, but imagine the situation when you have composable reducers as a modularization approach and query-based side effects. Here's some code, showing how you could create a child store (child module) from the main store (main module). The main idea here is that every module has its own store, but every child module has a piece of this store from the main module.

    public func scope<LocalState, LocalEvent: ComponentableEvent>(
        state toLocalState: @escaping (State) -> LocalState,
        event toGlobalEvent: @escaping (LocalEvent) -> Event,
        feedBacks: [SideEffect<LocalState, LocalEvent>]
    ) -> Store<LocalState, LocalEvent> where LocalEvent.State == LocalState {

        let localStore = Store<LocalState, LocalEvent>(
            initial: toLocalState(state),
            feedBacks: feedBacks,
            reducer: { localState, localEvent in

                var state = self.state
                self.reducer(&state, toGlobalEvent(localEvent))
                localState = toLocalState(state)

                if localEvent != LocalEvent.updateFromOutside(localState) {
                    self.accept(toGlobalEvent(localEvent))
                }

        }
        )

        $state
            .map(toLocalState)
            .removeDuplicates()
            .sink { [weak localStore] newValue in
                localStore?.accept(.updateFromOutside(newValue))
            }
            .store(in: &localStore.disposeBag)

        return localStore
    }
Enter fullscreen mode Exit fullscreen mode

In this case, we have a scope function which transforms the main store to the child one. Here SideEffect<LocalState, LocalEvent> is a pairing of query and action. The main idea of this approach is that while we create a child module, we inject side effects from it to the scope function. It gives us a way to construct modules with independent side effects. Every other action which is occurring in this function is mostly about main - child communication. It's necessary because we want to reflect changes from the main module inside the child one and visa versa.

There’s nothing hard to understand if you've already got how to handle modularization in general, trust me.

Outro

As usual, there's no silver bullet for modularization. You can decide for yourself what to use. So far, you've become familiar with how to work and even how to build your own reactive framework and unidirectional architecture. Actually you know most of these things already. However, in my opinion, there's still one important topic left - testing. Unidirectional architectures give us a super elegant way of testing your code. How to do the testing I will show you in the next article. So, let's keep in touch!

If you liked this article don't forget to crash a clap button. Moreover, you can do it 50 times, for you, it's super easy, but it will be really helpful for me.

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

Twitter(.atimca)
.subscribe(onNext: { newArcticle in
    you.read(newArticle)
})
Enter fullscreen mode Exit fullscreen mode

Discussion (0)